Skip to content

Allow focus moves within a document's own subtree - #2

Draft
ffiori wants to merge 1 commit into
mainfrom
focus-without-user-activation-threaded-source
Draft

Allow focus moves within a document's own subtree#2
ffiori wants to merge 1 commit into
mainfrom
focus-without-user-activation-threaded-source

Conversation

@ffiori

@ffiori ffiori commented Sep 4, 2026

Copy link
Copy Markdown
Owner

The "focus-without-user-activation" feature currently refuses any programmatic focus move into a document that is not allowed to use it, regardless of where the focus already is. Two consequences:

This closes both gaps. It supersedes whatwg#11519, which tried to close the first one by consulting the current global object inside the algorithm.

Discussion: whatwg#11839. Previous spec PR for this feature: whatwg#10672.

What changes

allow focus steps now also succeeds when the focus is already inside the target, and when the document responsible for the request could have taken the focus itself and the target is inside that document's subtree:

allow focus steps(target, source):
  1. target is allowed to use the feature                 -> true
  2. target's relevant global has transient activation    -> true
  3. target's node navigable is null                      -> false
  4. has focus steps(target)                              -> true
  5. source is null, or its node navigable is null        -> false
  6. source's inclusive descendant navigables does not
     contain target's node navigable                      -> false
  7. source is allowed to use the feature                 -> true
  8. source's relevant global has transient activation    -> true
  9. has focus steps(source)                              -> true
 10.                                                         false

Steps 5–9 read as: the source could have moved the focus to itself, and the target is inside the source's subtree. Steps 1 and 2 are unchanged from main and still evaluated first, so nothing that focuses today stops focusing.

Where the source comes from

To make "the document responsible for the request" well defined, focusing steps takes a source and returns whether the change was refused. The source is named at each call site rather than derived from an ambient global:

Call site source
HTMLOrSVGOrMathMLElement.focus() this's node document
autofocus, dialog focusing steps, popover focusing steps the subject's node document
Window.focus() the incumbent global object's associated Document
everything else (user-initiated focus, focus fixup, navigation focus reset) none — no check runs

Window.focus() is the one place an ambient lookup is needed, and it is needed there: someWindow.focus() is the same call whether a parent makes it on a child or a child makes it on itself, so the responsible document cannot be recovered from the arguments. It is derived from the incumbent for the same reason location.assign() derives its sourceDocument that way.

Side effect: the two focus APIs stop disagreeing

The check now runs after the step that redirects a navigable container to its content navigable, so it always evaluates the document the focus lands in. On main, iframeB.focus() is checked against the embedder while iframeB.contentWindow.focus() is checked against the child — which is the only reason focus delegation happens to work through one API and not the other. They are now gated identically.

Behaviour

A hosts B hosts C, with the feature denied for B and C:

Focused Call Result
B B focuses one of its own elements allow
B B focuses iframe C allow
C B focuses one of its own elements allow
A B focuses one of its own elements deny
A A calls iframeB.focus() allow
A A calls bWindow.focus() allow
A B calls window.focus() on itself deny
B B focuses a cousin navigable deny

Focus moves across top-level traversables are unaffected: a document whose node navigable has a null parent is always allowed to use a feature whose default allowlist is 'self', so step 1 short-circuits for any top-level target.

Considered and not done

A fullscreen carve-out. Raised in whatwg#11519 for A → B → C where C is fullscreen and B takes the focus. It cannot be made effective here: requestFullscreen() consumes activation, but any subsequent interaction with the fullscreen content runs the activation notification steps, which give every ancestor Window transient activation without consuming it — so step 2 short-circuits ahead of any guard. A guard placed before step 2 would instead stop a user-initiated move out of fullscreen. This looks like whatwg/fullscreen#15 rather than something for this feature; happy to add a carve-out if editors disagree.

Deciding purely from the target. Would remove the need for a source entirely, but then contentWindow.focus() could not be used for delegation, and the two focus APIs would stay inconsistent.

Points that need an explicit decision

  1. has focus steps is true for a document when the focus is in any of its descendants, so a document may take the focus back from a child navigable the user was interacting with. This is intended, and is the behaviour embedders have asked for, but it is worth a resolution rather than falling out of has focus steps.
  2. A document that is allowed to use the feature may delegate focus to any document in its subtree, including past an intermediate navigable that denied the feature. This follows from treating the embedder as the party that determines its descendants' policy.

(See WHATWG Working Mode: Changes for more details.)

The "focus-without-user-activation" feature previously refused any programmatic
focus move into a document that was not allowed to use it, regardless of where
the focus already was. That prevented an embedded document from managing focus
inside itself once it had been focused, and it left the TPAC 2024 resolution
that a parent may focus a child navigable unspecified.

The allow focus steps now also succeed when the focus is already inside the
target, and when the document responsible for the request could have taken the
focus itself and the target is inside that document's subtree.

To make "the document responsible for the request" well defined, the focusing
steps take a source and return whether the change was refused. The source is
named at each call site rather than derived from an ambient global, except for
Window.focus(), where the same call is made both by a parent on a child and by
a child on itself and so the responsible document cannot be recovered from the
arguments.

Because the check now runs after the focusing steps redirect a navigable
container to its content navigable, focus() on an iframe element and focus() on
that iframe's Window are gated identically; previously the element form was
checked against the embedder and the Window form against the child.

Closes whatwg#11839.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant